Skip to content

Conversation

@aojunhao123
Copy link
Contributor

@aojunhao123 aojunhao123 commented Dec 4, 2025

Summary by CodeRabbit

发布说明

  • 新功能

    • 支持按Escape键关闭弹窗
    • 新增onKeyDown回调属性,允许自定义键盘事件处理
  • 测试

    • 新增Escape键关闭弹窗的测试用例,涵盖简单和嵌套弹窗场景

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Dec 4, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

高层总览

此变更为Trigger组件的弹出框添加了Escape键盘事件处理功能。通过在TriggerProps、UniqueProviderProps和PopupProps中新增onKeyDown和onEsc属性,实现了当用户按下Escape键时关闭弹出框并调用回调函数的功能。同时增加了相应的测试用例以覆盖键盘交互场景。

变更清单

群组 / 文件 变更摘要
核心事件处理
src/index.tsx, src/UniqueProvider/index.tsx, src/Popup/index.tsx
添加Escape键处理支持:在TriggerProps、UniqueProviderProps和PopupProps中新增onKeyDown和onEsc属性;引入PortalProps类型以支持onEsc处理程序;在Trigger中创建onEsc处理程序用于关闭弹出框并转发键盘事件;将onEsc和onKeyDown属性穿透组件层级并传递给Popup和Portal组件
测试覆盖
tests/basic.test.jsx, tests/unique.test.tsx
新增键盘交互测试套件:验证Escape键关闭弹出框、非Escape键不关闭弹出框、嵌套弹出框从内向外依次关闭等场景
配置更新
tsconfig.json
在compilerOptions中新增types条目以包含"@testing-library/jest-dom"和"node"类型声明

事件流序列图

sequenceDiagram
    actor User
    participant Trigger
    participant Popup
    participant Portal
    
    User->>Trigger: 按下Escape键
    activate Trigger
    Trigger->>Trigger: 检测Escape键事件
    Note over Trigger: onEsc处理程序触发
    Trigger->>Popup: onEsc={onEsc}
    activate Popup
    Popup->>Portal: onEsc={onEsc}
    activate Portal
    Portal->>Portal: 关闭弹出框
    Portal-->>Trigger: 可选调用onKeyDown(event)
    deactivate Portal
    deactivate Popup
    deactivate Trigger
    User->>User: 弹出框关闭
Loading

代码审查工作量评估

🎯 2 (简单) | ⏱️ ~12 分钟

需要特别关注的区域:

  • src/index.tsx 中onEsc处理程序的实现逻辑,确保正确识别顶层弹出框状态
  • src/UniqueProvider/index.tsx 中onKeyDown回调的正确传递和调用时机
  • 嵌套弹出框场景下的Escape键处理顺序(内层优先关闭)

可能相关的PR

推荐审查人

  • zombieJ
  • MadCcc

庆祝诗

🐰 Escape键来敲一敲,弹窗乖乖都关掉,
回调函数顺序妙,嵌套弹窗由内往外跑,
键盘交互真美妙,用户体验闪闪耀!✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 标题准确概括了PR的主要变更:添加了通过Escape键关闭弹出菜单的功能支持。
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @aojunhao123, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request enhances the user experience by implementing the ability to close popups using the Escape key. It introduces a dedicated React hook to manage this behavior, ensuring proper handling of nested popups by closing them in a sequential, inside-out manner. The change is seamlessly integrated into the existing popup triggering mechanism and is accompanied by robust test coverage.

Highlights

  • New useEscCancel Hook: A new React hook, useEscCancel, has been introduced to manage the closing of popups when the 'Escape' key is pressed. This hook implements a stack-based mechanism to ensure that nested popups close in the correct last-in, first-out order.
  • Integration with generateTrigger: The useEscCancel hook has been integrated into the core generateTrigger function, automatically enabling Escape key functionality for all popups managed by this component.
  • Comprehensive Test Coverage: New test cases have been added to tests/basic.test.jsx to thoroughly verify the Escape key functionality, covering both single and nested popup closing scenarios.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@codecov
Copy link

codecov bot commented Dec 4, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.08%. Comparing base (38ac155) to head (4591871).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #594      +/-   ##
==========================================
+ Coverage   96.42%   97.08%   +0.66%     
==========================================
  Files          17       18       +1     
  Lines         950      995      +45     
  Branches      279      286       +7     
==========================================
+ Hits          916      966      +50     
+ Misses         34       29       -5     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
src/hooks/useEscCancel.ts (2)

19-38: Esc 处理函数可以加上更稳健的防御性判断

当前 handler 中直接从 stackMap 取值并访问 stack[stack.length - 1],如果以后有改动导致存在监听器但对应窗口没有栈或栈为空,按 Esc 会直接抛异常。removeEscListener 也会在 handler 不存在时多做一次无意义的 removeEventListener。建议轻量加一层判空,提升健壮性:

  const handler = (event: KeyboardEvent) => {
    if (event.key !== 'Escape') {
      return;
    }

-    const stack = stackMap.get(win);
-
-    const top = stack[stack.length - 1];
-    top.triggerOpen(false);
+    const stack = stackMap.get(win);
+    if (!stack?.length) {
+      return;
+    }
+
+    const top = stack[stack.length - 1];
+    top.triggerOpen(false);
  };

以及:

 function removeEscListener(win: Window) {
-  const handler = handlerMap.get(win);
-  win.removeEventListener('keydown', handler);
-  handlerMap.delete(win);
+  const handler = handlerMap.get(win);
+  if (!handler) {
+    return;
+  }
+  win.removeEventListener('keydown', handler);
+  handlerMap.delete(win);
 }

这不会改变现有行为,只是避免未来改动时出现难排查的运行时错误。


65-89: useEscCancelpopupEle 类型建议允许 null 以匹配调用方

Hook 签名中 popupEle 声明为 HTMLElement,但在 Trigger 里传入的是可能为 null 的 state(初始值就是 null),同时 effect 已经通过 if (!open || !popupEle) 做了运行时防御。为让类型与实际使用一致,可以小改一下:

-export default function useEscCancel(
-  popupId: string,
-  open: boolean,
-  popupEle: HTMLElement,
-  triggerOpen: (open: boolean) => void,
-) {
+export default function useEscCancel(
+  popupId: string,
+  open: boolean,
+  popupEle: HTMLElement | null,
+  triggerOpen: (open: boolean) => void,
+) {

这样可以避免调用处/后续重构引入多余的类型断言。

tests/basic.test.jsx (1)

1220-1269: 嵌套 Esc 测试建议显式刷一次计时器以避免潜在时序问题

esc should close nested popup from inside out 中,两次 fireEvent.keyDown(window, { key: 'Escape' }) 后直接做同步断言。如果 triggerOpen(false)useDelay 走的是 setTimeout(..., 0) 之类实现,这里可能会产生依赖实现细节的时序问题或偶现失败。

建议在两次 Esc 之后分别调用 awaitFakeTimer(),与前一个 Esc 用例及其它依赖延时的测试保持一致,例如:

        fireEvent.keyDown(window, { key: 'Escape' });
-        expect(isPopupClassHidden('.inner-popup')).toBeTruthy();
-        expect(isPopupClassHidden('.outer-popup')).toBeFalsy();
+        await awaitFakeTimer();
+        expect(isPopupClassHidden('.inner-popup')).toBeTruthy();
+        expect(isPopupClassHidden('.outer-popup')).toBeFalsy();
 
        fireEvent.keyDown(window, { key: 'Escape' });
-        expect(isPopupClassHidden('.outer-popup')).toBeTruthy();
+        await awaitFakeTimer();
+        expect(isPopupClassHidden('.outer-popup')).toBeTruthy();

另外,使用

const useIdModule = require('@rc-component/util/lib/hooks/useId');
const useIdSpy = jest.spyOn(useIdModule, 'default').mockImplementation(...);

的方式来稳定 id 顺序在当前 CJS/interop 流程下是可行的;如果后续构建切到纯 ESM,可以留意一下是否需要改成 jest.mock 形式来继续保证这一用例的稳定性。

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 338a80f and 57a27f7.

📒 Files selected for processing (3)
  • src/hooks/useEscCancel.ts (1 hunks)
  • src/index.tsx (2 hunks)
  • tests/basic.test.jsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
tests/basic.test.jsx (1)
tests/util.tsx (1)
  • awaitFakeTimer (97-104)
src/index.tsx (1)
src/hooks/useEscCancel.ts (1)
  • useEscCancel (65-89)
src/hooks/useEscCancel.ts (1)
src/util.ts (1)
  • getWin (39-41)
🔇 Additional comments (2)
src/index.tsx (1)

19-19: Esc 关闭逻辑在 Trigger 中的集成是合理的

Trigger 内引入并调用 useEscCancel(id, mergedOpen, popupEle, triggerOpen)

  • 使用的是合并后的可见状态 mergedOpen,与现有逻辑保持一致;
  • 通过传入统一的 triggerOpen,Esc 关闭会走同一套受控/非受控、UniqueProvider 分支和回调链;
  • 调用位置固定且无条件,不会破坏 Hooks 调用顺序。

整体接入看起来没有明显问题。

Also applies to: 651-652

tests/basic.test.jsx (1)

1204-1218: 基础 Esc 关闭用例覆盖合理

这里通过点击触发弹层,再触发 keyDown(Escape) 并用 awaitFakeTimer() 刷新定时器后断言隐藏,验证了 Esc 走的是与其它关闭路径一致的异步流程,写法和文件里现有依赖计时器的用例风格一致,看起来没有问题。

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new feature to close popups by pressing the Escape key. A new hook useEscCancel is created to manage a stack of open popups and handle the keydown events. The implementation is clean and includes good test coverage for both single and nested popups. I've added a couple of suggestions in useEscCancel.ts to improve robustness by adding defensive checks against potential edge cases.


const stack = stackMap.get(win);

const top = stack[stack.length - 1];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stack 其实不靠谱,因为用户是可以通过 zIndex 来调整展示先后的。所以很有可能先出来的弹层反而在最前面

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个倒是确实没考虑到这种情况。组件库层面要处理这种因为UI交互设计不规范造成的corner case吗

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
src/hooks/useEscKeyDown.ts (1)

24-28: 在访问 stack 前需要添加空值检查

stackMap.get(win) 可能返回 undefined,直接访问 stack[stack.length - 1] 会导致 TypeError。虽然当前逻辑中 registerEscEntry 会先于 handler 执行填充 stack,但添加防御性检查更为稳健。

🔎 建议的修复方案
  const handler = (event: KeyboardEvent) => {
    if (event.key !== 'Escape') {
      return;
    }

    const stack = stackMap.get(win);
+   if (!stack?.length) {
+     return;
+   }

-   const top = stack[stack.length - 1];
-   top.triggerOpen(false);
+   const top = stack[stack.length - 1];
+   top?.triggerOpen(false);
  };
🧹 Nitpick comments (2)
src/hooks/useEscKeyDown.ts (1)

34-38: 移除事件监听器前应检查 handler 是否存在

handlerMap.get(win) 可能返回 undefined,应在移除监听器前进行检查,避免无效操作。

🔎 建议的修复方案
 function removeEscListener(win: Window) {
   const handler = handlerMap.get(win);
-  win.removeEventListener('keydown', handler);
-  handlerMap.delete(win);
+  if (handler) {
+    win.removeEventListener('keydown', handler);
+    handlerMap.delete(win);
+  }
 }
tests/unique.test.tsx (1)

379-379: 格式问题:解构赋值缺少空格

{ container,baseElement } 中逗号后缺少空格,应为 { container, baseElement }

🔎 建议的修复
-    const { container,baseElement } = render(
+    const { container, baseElement } = render(
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 881eacc and 4591871.

📒 Files selected for processing (6)
  • src/UniqueProvider/index.tsx (2 hunks)
  • src/hooks/useEscKeyDown.ts (1 hunks)
  • src/index.tsx (2 hunks)
  • tests/basic.test.jsx (1 hunks)
  • tests/unique.test.tsx (1 hunks)
  • tsconfig.json (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/index.tsx
  • tsconfig.json
🧰 Additional context used
🧬 Code graph analysis (4)
tests/unique.test.tsx (2)
src/index.tsx (1)
  • UniqueProvider (39-39)
tests/util.tsx (1)
  • awaitFakeTimer (97-104)
src/UniqueProvider/index.tsx (1)
src/hooks/useEscKeyDown.ts (1)
  • useEscKeyDown (65-89)
tests/basic.test.jsx (1)
tests/util.tsx (1)
  • awaitFakeTimer (97-104)
src/hooks/useEscKeyDown.ts (1)
src/util.ts (1)
  • getWin (38-40)
🔇 Additional comments (8)
src/hooks/useEscKeyDown.ts (3)

40-54: LGTM!

unregisterEscEntry 正确处理了 stack 的空值检查,并在 stack 为空时清理监听器。


56-63: LGTM!

注册逻辑正确处理了重复注册的情况,先过滤掉相同 id 的旧条目再添加新条目。


65-89: Hook 实现正确,但需注意 zIndex 场景的局限性

Hook 逻辑清晰,正确处理了注册/注销生命周期。但如过往评论所述,基于栈的关闭顺序可能与用户通过 zIndex 自定义的视觉层级不一致。如果用户手动调整了弹层的 zIndex,后打开的弹层可能视觉上在下层,但按 Escape 仍会先关闭它。

这是一个已知的设计限制,如果需要支持 zIndex 感知的关闭顺序,可能需要额外的实现复杂度。

src/UniqueProvider/index.tsx (1)

18-18: LGTM!

useEscKeyDown 的集成正确。使用 mergedOptions?.id 进行可选链访问,并正确传递关闭回调 () => trigger(false)

Also applies to: 95-96

tests/unique.test.tsx (1)

378-393: 测试用例逻辑正确

测试正确验证了 UniqueProvider 中 Escape 键关闭弹层的功能,包括打开弹层、按下 Escape、验证关闭状态的完整流程。

tests/basic.test.jsx (3)

1205-1218: LGTM!

基础 Escape 键关闭测试用例正确,覆盖了打开、按 Escape、验证关闭的完整流程。


1220-1232: LGTM!

负面测试用例正确验证了非 Escape 键不会关闭弹层。


1234-1283: LGTM!

嵌套弹层测试用例设计良好:

  1. 使用 mock 确保 ID 的可预测性
  2. 正确验证了由内向外的关闭顺序
  3. 使用 try/finally 确保 mock 正确恢复

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
tests/basic.test.jsx (2)

1241-1253: 建议添加 await 以保持测试一致性

在第一个测试(第 1237 行)中,在 fireEvent.keyDown 后使用了 await awaitFakeTimer(),但此测试中未使用。虽然此测试期望不发生变化,但为了测试一致性和可靠性,建议也添加 await awaitFakeTimer()

🔎 建议的修改
     fireEvent.keyDown(window, { key: 'Enter' });
+    await awaitFakeTimer();
     expect(isPopupHidden()).toBeFalsy();

1255-1295: 嵌套弹窗测试逻辑正确,建议添加异步等待

此测试正确验证了嵌套弹窗的"由内而外"关闭行为。但是,与第一个测试不同,在 fireEvent.keyDown 后没有使用 await awaitFakeTimer()(第 1288、1292 行)。

建议在每次 keyDown 事件后添加 await awaitFakeTimer(),以确保状态更新完成并保持测试一致性。

🔎 建议的修改
     fireEvent.keyDown(window, { key: 'Escape' });
+    await awaitFakeTimer();
     expect(isPopupClassHidden('.inner-popup')).toBeTruthy();
     expect(isPopupClassHidden('.outer-popup')).toBeFalsy();

     fireEvent.keyDown(window, { key: 'Escape' });
+    await awaitFakeTimer();
     expect(isPopupClassHidden('.outer-popup')).toBeTruthy();
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4591871 and 26500eb.

📒 Files selected for processing (4)
  • src/Popup/index.tsx
  • src/UniqueProvider/index.tsx
  • src/index.tsx
  • tests/basic.test.jsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/UniqueProvider/index.tsx
  • src/index.tsx
🧰 Additional context used
🪛 GitHub Actions: ✅ test
src/Popup/index.tsx

[error] 28-28: Property 'onEsc' does not exist on type 'PortalProps'.

🔇 Additional comments (1)
tests/basic.test.jsx (1)

1204-1224: 测试辅助工具实现良好

使用 useId mock 来稳定测试中生成的 ID 是一个很好的做法。实现正确地使用了 React.useRef 来维护 ID 的稳定性,并且正确地在 afterEach 中清理 mock。

import PopupContent from './PopupContent';
import useOffsetStyle from '../hooks/useOffsetStyle';
import { useEvent } from '@rc-component/util';
import type { PortalProps } from '@rc-component/portal';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

TypeError:PortalProps 类型缺少 onEsc 属性

根据 @rc-component/portal 的类型定义,PortalProps 接口中不存在 onEsc 属性。尝试使用 onEsc?: PortalProps['onEsc'] 会导致 TypeScript 编译失败。

当前版本 @rc-component/portal@2.0.0 不支持该属性。解决方案:

  1. 如果需要 Escape 键关闭功能,需要在 PopupProps 中直接定义 onEsc 类型,而不是从 PortalProps 继承
  2. 或者考虑为 Portal 组件的业务包装层单独处理 ESC 键事件
🤖 Prompt for AI Agents
In src/Popup/index.tsx around line 17, the code attempts to reference
PortalProps.onEsc but @rc-component/portal v2.0.0's PortalProps has no onEsc,
causing a TypeScript error; to fix, stop trying to inherit onEsc from
PortalProps and instead add an explicit onEsc?: (e: KeyboardEvent) => void (or
the appropriate signature) to your PopupProps interface, or handle ESC key logic
inside the Popup wrapper (attach a keydown listener that calls a local onEsc
handler) so types align with the external library.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants