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]);