fix(panel): let Escape close only the topmost full-screen panel

Each FullScreenPanel adds its own keydown listener on window, and
stopPropagation does not stop other listeners on the same target, so
stacked panels (pricing -> pricing editor, edit provider -> endpoint
speed test) all closed on a single Escape. Track open panels in a
module-level stack and only let the top one react.
This commit is contained in:
Jason committed 2026-10-23 11:15:39 +08:00
1 parent 7cc3141cae
commit f4e9211a11
2 files changed
+86 -2

No files matched your search

+17
View File
@@ -58,6 +58,13 @@ const unlockBodyScroll = () => {
}
};
/**
* 打开着的面板,后打开的在最后。二级页里再开二级页(定价 → 编辑定价、编辑供应商 → 测速)时
* 每层都在 window 上挂了 ESC 监听,stopPropagation 拦不住同一目标上的其他监听,
* 所以只让栈顶那层响应,一次 ESC 只退一层。
*/
const openPanelStack: object[] = [];
/**
* Reusable full-screen panel component
* Handles portal rendering, header with back button, and footer
@@ -103,8 +110,16 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
React.useEffect(() => {
if (!isOpen) return;
const entry = {};
openPanelStack.push(entry);
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
// 上面还叠着别的面板:交给最上面那层
if (openPanelStack[openPanelStack.length - 1] !== entry) {
return;
}
// 子组件(例如 Radix 的 Select/Dialog/Dropdown)如果已经消费了 ESC,就不要再关闭整个面板
if (event.defaultPrevented) {
return;
@@ -123,6 +138,8 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
window.addEventListener("keydown", handleKeyDown, false);
return () => {
window.removeEventListener("keydown", handleKeyDown, false);
const index = openPanelStack.indexOf(entry);
if (index >= 0) openPanelStack.splice(index, 1);
};
}, [isOpen]);
+69 -2
View File
@@ -1,5 +1,5 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { DRAG_REGION_ENABLED } from "@/lib/platform";
@@ -75,6 +75,73 @@ describe("FullScreenPanel body scroll locking", () => {
});
});
describe("FullScreenPanel Escape", () => {
const Stacked = ({
innerOpen,
onOuterClose,
onInnerClose,
}: {
innerOpen: boolean;
onOuterClose: () => void;
onInnerClose: () => void;
}) => (
<>
<FullScreenPanel isOpen title="Pricing" onClose={onOuterClose}>
pricing
</FullScreenPanel>
<FullScreenPanel
isOpen={innerOpen}
title="Edit pricing"
onClose={onInnerClose}
>
<input aria-label="price" />
</FullScreenPanel>
</>
);
it("closes only the topmost of two stacked panels", async () => {
const onOuterClose = vi.fn();
const onInnerClose = vi.fn();
const view = render(
<Stacked
innerOpen={false}
onOuterClose={onOuterClose}
onInnerClose={onInnerClose}
/>,
);
// 子页后打开,叠在上面
view.rerender(
<Stacked
innerOpen
onOuterClose={onOuterClose}
onInnerClose={onInnerClose}
/>,
);
fireEvent.keyDown(document.body, { key: "Escape" });
expect(onInnerClose).toHaveBeenCalledTimes(1);
expect(onOuterClose).not.toHaveBeenCalled();
// 输入框里的 ESC 留给输入框,哪层都不关
fireEvent.keyDown(screen.getByLabelText("price"), { key: "Escape" });
expect(onInnerClose).toHaveBeenCalledTimes(1);
expect(onOuterClose).not.toHaveBeenCalled();
// 子页关掉后,下一次 ESC 才轮到下面那层
view.rerender(
<Stacked
innerOpen={false}
onOuterClose={onOuterClose}
onInnerClose={onInnerClose}
/>,
);
await waitFor(() => expect(screen.queryByLabelText("price")).toBeNull());
fireEvent.keyDown(document.body, { key: "Escape" });
expect(onOuterClose).toHaveBeenCalledTimes(1);
expect(onInnerClose).toHaveBeenCalledTimes(1);
});
});
describe("FullScreenPanel header", () => {
it("lets a long title shrink and truncate instead of pushing the window controls away", () => {
const title = "Edit " + "a-very-long-user-provided-name-".repeat(8);