From d12939da386e2617e50410d2b4ea75fc32382dea Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Mon, 15 Dec 2025 01:54:08 +0800 Subject: [PATCH 1/7] init --- .gitignore | 1 + package.json | 2 +- script/update-content.js | 34 ---------------------------------- src/useEscKeyDown.ts | 0 4 files changed, 2 insertions(+), 35 deletions(-) delete mode 100644 script/update-content.js create mode 100644 src/useEscKeyDown.ts 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/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/useEscKeyDown.ts b/src/useEscKeyDown.ts new file mode 100644 index 0000000..e69de29 From 3b47b490930cdaec8dee3d57636fc40478603f92 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Mon, 22 Dec 2025 00:30:12 +0800 Subject: [PATCH 2/7] feat: add escape handling for better a11y --- src/Portal.tsx | 14 +++++++++ src/useEscKeyDown.ts | 43 +++++++++++++++++++++++++ tests/index.test.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 131 insertions(+), 1 deletion(-) diff --git a/src/Portal.tsx b/src/Portal.tsx index 3678b74..490f4e4 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 = ({ + isTop, + event, +}: { + isTop: 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 index e69de29..e270504 100644 --- a/src/useEscKeyDown.ts +++ b/src/useEscKeyDown.ts @@ -0,0 +1,43 @@ +import { useEffect, useContext } from 'react'; +import { type EscCallback } from './Portal'; +import useId from '@rc-component/util/lib/hooks/useId'; +import { useEvent } from '@rc-component/util'; +import OrderContext from './Context'; + +let stack: string[] = []; + +export default function useEscKeyDown(open: boolean, onEsc?: EscCallback) { + const id = useId(); + + const queueCreate = useContext(OrderContext); + + const handleEscKeyDown = useEvent((event: KeyboardEvent) => { + if (event.key === 'Escape') { + const isTop = stack[stack.length - 1] === id; + onEsc?.({ isTop, event }); + } + }); + + useEffect(() => { + if (!open) { + return; + } + + const pushToStack = () => { + stack.push(id); + }; + + if (queueCreate) { + queueCreate(pushToStack); + } else { + pushToStack(); + } + + window.addEventListener('keydown', handleEscKeyDown); + + return () => { + stack = stack.filter(item => item !== id); + window.removeEventListener('keydown', handleEscKeyDown); + }; + }, [open, id]); +} diff --git a/tests/index.test.tsx b/tests/index.test.tsx index e8d77fb..3c30993 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'; @@ -290,4 +290,77 @@ describe('Portal', () => { expect(document.querySelector('.checker').textContent).toEqual('true'); }); + + describe('onEsc', () => { + beforeEach(() => { + const useIdModule = require('@rc-component/util/lib/hooks/useId'); + let seed = 0; + jest + .spyOn(useIdModule, 'default') + .mockImplementation(() => `test-${(seed += 1)}`); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + 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({ isTop: false }), + ); + expect(onEscB).toHaveBeenCalledWith( + expect.objectContaining({ isTop: 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({ isTop: true }), + ); + expect(onEscB).not.toHaveBeenCalled(); + }); + }); }); From 6d93e00c65d40632c959cdfade0bcac4142212b2 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Mon, 22 Dec 2025 00:30:39 +0800 Subject: [PATCH 3/7] adjust demo --- docs/examples/basic.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/examples/basic.tsx b/docs/examples/basic.tsx index 3844a92..221a775 100644 --- a/docs/examples/basic.tsx +++ b/docs/examples/basic.tsx @@ -41,11 +41,17 @@ export default () => { />
- + { + console.log('root onEsc', { isTop, event }); + }}>

Hello Root

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

Hello Parent

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

Hello Children

From bc940e605e1362b414fbaeb4df2839d1c200cf2f Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Mon, 22 Dec 2025 16:43:17 +0800 Subject: [PATCH 4/7] push stack in render --- docs/examples/basic.tsx | 12 ++++++------ src/Portal.tsx | 4 ++-- src/useEscKeyDown.ts | 37 ++++++++++++++++++++----------------- tests/index.test.tsx | 20 +++++++------------- tests/testUtils.ts | 27 +++++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 38 deletions(-) create mode 100644 tests/testUtils.ts diff --git a/docs/examples/basic.tsx b/docs/examples/basic.tsx index 221a775..b1bb1ea 100644 --- a/docs/examples/basic.tsx +++ b/docs/examples/basic.tsx @@ -41,16 +41,16 @@ export default () => { />
- { - console.log('root onEsc', { isTop, event }); + { + console.log('root onEsc', { top, event }); }}>

Hello Root

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

Hello Parent

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

Hello Children

diff --git a/src/Portal.tsx b/src/Portal.tsx index 490f4e4..c92dce0 100644 --- a/src/Portal.tsx +++ b/src/Portal.tsx @@ -22,10 +22,10 @@ export type GetContainer = | false; export type EscCallback = ({ - isTop, + top, event, }: { - isTop: boolean; + top: boolean; event: KeyboardEvent; }) => void; diff --git a/src/useEscKeyDown.ts b/src/useEscKeyDown.ts index e270504..1fc8d15 100644 --- a/src/useEscKeyDown.ts +++ b/src/useEscKeyDown.ts @@ -1,42 +1,45 @@ -import { useEffect, useContext } from 'react'; +import { useEffect, useRef } from 'react'; import { type EscCallback } from './Portal'; import useId from '@rc-component/util/lib/hooks/useId'; import { useEvent } from '@rc-component/util'; -import OrderContext from './Context'; let stack: string[] = []; export default function useEscKeyDown(open: boolean, onEsc?: EscCallback) { const id = useId(); - - const queueCreate = useContext(OrderContext); + const inStackRef = useRef(false); const handleEscKeyDown = useEvent((event: KeyboardEvent) => { if (event.key === 'Escape') { - const isTop = stack[stack.length - 1] === id; - onEsc?.({ isTop, event }); + const top = stack[stack.length - 1] === id; + onEsc?.({ top, event }); } }); + if (open) { + if (!inStackRef.current) { + stack.push(id); + inStackRef.current = true; + } + } else { + if (inStackRef.current) { + stack = stack.filter(item => item !== id); + inStackRef.current = false; + } + } + useEffect(() => { if (!open) { return; } - const pushToStack = () => { - stack.push(id); - }; - - if (queueCreate) { - queueCreate(pushToStack); - } else { - pushToStack(); - } - window.addEventListener('keydown', handleEscKeyDown); return () => { - stack = stack.filter(item => item !== id); + if (inStackRef.current) { + stack = stack.filter(item => item !== id); + inStackRef.current = false; + } window.removeEventListener('keydown', handleEscKeyDown); }; }, [open, id]); diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 3c30993..9a64ec2 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -1,6 +1,7 @@ import { render, fireEvent } from '@testing-library/react'; import React from 'react'; import Portal from '../src'; +import { mockUseId } from './testUtils'; global.isOverflow = true; @@ -292,17 +293,10 @@ describe('Portal', () => { }); describe('onEsc', () => { - beforeEach(() => { - const useIdModule = require('@rc-component/util/lib/hooks/useId'); - let seed = 0; - jest - .spyOn(useIdModule, 'default') - .mockImplementation(() => `test-${(seed += 1)}`); - }); + const { setup, cleanup } = mockUseId(); - afterEach(() => { - jest.restoreAllMocks(); - }); + beforeEach(() => setup()); + afterEach(() => cleanup()); it('only last opened portal is top', () => { const onEscA = jest.fn(); @@ -322,10 +316,10 @@ describe('Portal', () => { fireEvent.keyDown(window, { key: 'Escape' }); expect(onEscA).toHaveBeenCalledWith( - expect.objectContaining({ isTop: false }), + expect.objectContaining({ top: false }), ); expect(onEscB).toHaveBeenCalledWith( - expect.objectContaining({ isTop: true }), + expect.objectContaining({ top: true }), ); }); @@ -358,7 +352,7 @@ describe('Portal', () => { fireEvent.keyDown(window, { key: 'Escape' }); expect(onEscA).toHaveBeenCalledWith( - expect.objectContaining({ isTop: true }), + expect.objectContaining({ top: true }), ); expect(onEscB).not.toHaveBeenCalled(); }); diff --git a/tests/testUtils.ts b/tests/testUtils.ts new file mode 100644 index 0000000..dbc8419 --- /dev/null +++ b/tests/testUtils.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; + +export function mockUseId() { + const useIdModule = require('@rc-component/util/lib/hooks/useId'); + let useIdSpy: jest.SpyInstance; + let uuid = 0; + + const setup = () => { + uuid = 0; + useIdSpy = jest.spyOn(useIdModule, 'default').mockImplementation(() => { + const idRef = React.useRef(''); + + if (!idRef.current) { + uuid += 1; + idRef.current = `test-id-${uuid}`; + } + + return idRef.current; + }); + }; + + const cleanup = () => { + useIdSpy?.mockRestore(); + }; + + return { setup, cleanup }; +} From 044ca9409f1fbb9446273bfb6ae20a24269a047e Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Mon, 22 Dec 2025 17:52:22 +0800 Subject: [PATCH 5/7] chore: adjust --- src/useEscKeyDown.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/useEscKeyDown.ts b/src/useEscKeyDown.ts index 1fc8d15..bf94720 100644 --- a/src/useEscKeyDown.ts +++ b/src/useEscKeyDown.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +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'; @@ -7,7 +7,6 @@ let stack: string[] = []; export default function useEscKeyDown(open: boolean, onEsc?: EscCallback) { const id = useId(); - const inStackRef = useRef(false); const handleEscKeyDown = useEvent((event: KeyboardEvent) => { if (event.key === 'Escape') { @@ -16,17 +15,13 @@ export default function useEscKeyDown(open: boolean, onEsc?: EscCallback) { } }); - if (open) { - if (!inStackRef.current) { + useMemo(() => { + if (open) { stack.push(id); - inStackRef.current = true; - } - } else { - if (inStackRef.current) { + } else { stack = stack.filter(item => item !== id); - inStackRef.current = false; } - } + }, [open, id]); useEffect(() => { if (!open) { @@ -36,10 +31,6 @@ export default function useEscKeyDown(open: boolean, onEsc?: EscCallback) { window.addEventListener('keydown', handleEscKeyDown); return () => { - if (inStackRef.current) { - stack = stack.filter(item => item !== id); - inStackRef.current = false; - } window.removeEventListener('keydown', handleEscKeyDown); }; }, [open, id]); From bfbaef8f9b30913a593f81b6848a1e0b39efef01 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Mon, 22 Dec 2025 22:46:12 +0800 Subject: [PATCH 6/7] mock useId --- tests/index.test.tsx | 11 +++++------ tests/testUtils.ts | 27 --------------------------- 2 files changed, 5 insertions(+), 33 deletions(-) delete mode 100644 tests/testUtils.ts diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 9a64ec2..72fa6a1 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -1,7 +1,6 @@ import { render, fireEvent } from '@testing-library/react'; import React from 'react'; import Portal from '../src'; -import { mockUseId } from './testUtils'; global.isOverflow = true; @@ -19,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; @@ -293,11 +297,6 @@ describe('Portal', () => { }); describe('onEsc', () => { - const { setup, cleanup } = mockUseId(); - - beforeEach(() => setup()); - afterEach(() => cleanup()); - it('only last opened portal is top', () => { const onEscA = jest.fn(); const onEscB = jest.fn(); diff --git a/tests/testUtils.ts b/tests/testUtils.ts deleted file mode 100644 index dbc8419..0000000 --- a/tests/testUtils.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as React from 'react'; - -export function mockUseId() { - const useIdModule = require('@rc-component/util/lib/hooks/useId'); - let useIdSpy: jest.SpyInstance; - let uuid = 0; - - const setup = () => { - uuid = 0; - useIdSpy = jest.spyOn(useIdModule, 'default').mockImplementation(() => { - const idRef = React.useRef(''); - - if (!idRef.current) { - uuid += 1; - idRef.current = `test-id-${uuid}`; - } - - return idRef.current; - }); - }; - - const cleanup = () => { - useIdSpy?.mockRestore(); - }; - - return { setup, cleanup }; -} From db063a428c5ef2b662fd782bebf9c26d62c82ad2 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Tue, 23 Dec 2025 11:20:38 +0800 Subject: [PATCH 7/7] prevent duplicate stack push --- src/useEscKeyDown.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/useEscKeyDown.ts b/src/useEscKeyDown.ts index bf94720..79c022e 100644 --- a/src/useEscKeyDown.ts +++ b/src/useEscKeyDown.ts @@ -16,9 +16,9 @@ export default function useEscKeyDown(open: boolean, onEsc?: EscCallback) { }); useMemo(() => { - if (open) { + if (open && !stack.includes(id)) { stack.push(id); - } else { + } else if (!open) { stack = stack.filter(item => item !== id); } }, [open, id]);