Skip to content

fix: lock focus inside preview when opened via keyboard#505

Merged
zombieJ merged 2 commits intoreact-component:masterfrom
aojunhao123:fix/preview-focus-lock
Apr 8, 2026
Merged

fix: lock focus inside preview when opened via keyboard#505
zombieJ merged 2 commits intoreact-component:masterfrom
aojunhao123:fix/preview-focus-lock

Conversation

@aojunhao123
Copy link
Copy Markdown
Contributor

@aojunhao123 aojunhao123 commented Apr 3, 2026

Screenshot

Before

Clipboard-20260403-192533-706

After

Clipboard-20260403-192350-368

CSSMotion 导致的渲染时序问题,useLockfoucs 执行时 wrapper dom 还没被挂载,但是该 hooks 的 deps 是[id,lock],导致 wrapper dom 挂载后也不会重新触发,导致 focus trap 失效。

改成 state callback ref,re-render 一次让 useLockFocus 触发 在 util 侧解了:https://github.com/react-component/util/pull/748/changes

🤓 Generated with 我自己

Summary

  • Fix focus not being trapped inside preview when opened via keyboard (Enter/Space). Root cause: CSSMotion returns null on first render (styleReady='NONE' during appear phase), so the wrapper DOM element is not available when useLockFocus first activates. Switched wrapperRef from useRef to useState callback ref so the component re-renders once the wrapper mounts, allowing useLockFocus to correctly lock focus.
  • Restore focus to the trigger element after preview closes. Records document.activeElement as the trigger when open becomes true, then calls triggerRef.current.focus() in onVisibleChanged after the leave animation completes.

Test plan

  • Added test: focus is trapped inside preview after keyboard open
  • Added test: focus is restored to trigger element after preview close
  • Manual test: open preview with Enter key, verify Tab cycles within preview
  • Manual test: close preview with Escape, verify focus returns to the image

🤖 Generated with Claude Code

Summary by CodeRabbit

发布说明

  • Bug Fixes

    • 改进了预览的焦点管理,确保在关闭预览窗口时焦点能够正确恢复到触发元素。
  • Tests

    • 添加了测试用例以验证键盘导航和焦点锁定行为。

…eyboard

The preview wrapper DOM element is not immediately available due to
CSSMotion's deferred rendering (styleReady='NONE' on first render).
Using useRef meant useLockFocus could never re-evaluate after the DOM
appeared. Switch to useState callback ref so the component re-renders
once the wrapper mounts, allowing useLockFocus to activate correctly.

Also restores focus to the trigger element after the preview closes.
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 3, 2026

@aojunhao123 is attempting to deploy a commit to the React Component Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 3, 2026

Walkthrough

在 Preview 组件中新增 triggerRef,在打开时通过 useLayoutEffect 捕获当前活动元素;在可见性变为隐藏时(nextVisible === false)将焦点恢复到该触发元素并清空 triggerRef。焦点锁定逻辑仍然存在,hook 的激活条件为 open && portalRender(目标为现有的 wrapperRef)。

Changes

Cohort / File(s) Summary
Preview 焦点与可见性逻辑
src/Preview/index.tsx
新增 triggerRef,在 open 时通过 useLayoutEffect 保存 document.activeElement;在 onVisibleChanged(nextVisible === false) 恢复焦点并清空 triggerRef。焦点锁定仍使用已有 wrapper 引用,激活条件为 open && portalRender
焦点行为测试
tests/preview.test.tsx
新增测试:mock HTMLElement.prototype.getBoundingClientRect,验证键盘打开预览、焦点进入预览、焦点被锁定不能逃出、按 Escape 关闭后焦点恢复到触发元素,并在最后还原 spy。
依赖更新
package.json
@rc-component/util 版本从 ^1.10.0 升级为 ^1.10.1

Sequence Diagram(s)

sequenceDiagram
    participant User as 用户
    participant TriggerEl as 触发元素
    participant Preview as Preview 组件
    participant FocusLock as 焦点锁定 Hook
    participant WrapperEl as Wrapper 元素

    User->>TriggerEl: 按 Enter 键或激活触发器
    TriggerEl->>Preview: 触发 open=true
    Preview->>Preview: useLayoutEffect 捕获 document.activeElement -> triggerRef
    Preview->>FocusLock: 激活焦点锁定 (open && portalRender)
    FocusLock->>WrapperEl: 将焦点限制在 Wrapper 内

    User->>TriggerEl: 尝试重新聚焦触发元素
    FocusLock->>User: 阻止焦点离开 Wrapper

    User->>Preview: 按 Escape 关闭预览
    Preview->>Preview: onVisibleChanged(nextVisible=false)
    Preview->>TriggerEl: 若可聚焦则 restore focus to triggerRef.current
    Preview->>Preview: 清空 triggerRef
Loading

预计代码审查工作量

🎯 3 (Moderate) | ⏱️ ~20 分钟

可能相关的 PR

建议审查者

  • zombieJ
  • yoyo837

诗歌

🐰 我是小兔守焦点,轻轻一跃进预览,
捕住瞬间的光与键,锁住视线不迷路。
关上门来归原位,触发元素又暖又熟。

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 标题准确反映了主要变更:修复了通过键盘打开预览时焦点未被锁定的问题。标题简洁清晰,与所有更改内容(焦点锁定、焦点恢复、依赖版本更新)都相关。
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

Copy link
Copy Markdown
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 implements focus management for the Preview component, ensuring that focus is captured when the preview opens and restored to the original trigger element upon closing. It also includes a new test case to verify focus trapping and restoration behavior. Feedback focuses on making the focus restoration more robust by checking if the trigger element is still in the document and using the preventScroll option, as well as refining the logic for capturing the trigger element to avoid overwriting it with elements from within the preview itself.

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 3, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.44%. Comparing base (a2c9ca2) to head (ad46478).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #505   +/-   ##
=======================================
  Coverage   99.43%   99.44%           
=======================================
  Files          17       17           
  Lines         532      538    +6     
  Branches      161      162    +1     
=======================================
+ Hits          529      535    +6     
  Misses          3        3           

☔ 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
Copy Markdown

@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.

🧹 Nitpick comments (1)
src/Preview/index.tsx (1)

200-200: TypeScript 类型定义不完整

useState<HTMLDivElement>(null) 的泛型参数应包含 null,否则在严格模式下会有类型错误。

建议修复
- const [wrapperEl, setWrapperEl] = useState<HTMLDivElement>(null);
+ const [wrapperEl, setWrapperEl] = useState<HTMLDivElement | null>(null);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Preview/index.tsx` at line 200, The state declaration for wrapperEl uses
useState<HTMLDivElement>(null) which omits null in the generic and causes type
errors in strict mode; update the useState generic to include null (e.g.,
useState<HTMLDivElement | null>(null)) so wrapperEl and setWrapperEl have the
correct nullable type and TypeScript no longer complains.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/Preview/index.tsx`:
- Line 200: The state declaration for wrapperEl uses
useState<HTMLDivElement>(null) which omits null in the generic and causes type
errors in strict mode; update the useState generic to include null (e.g.,
useState<HTMLDivElement | null>(null)) so wrapperEl and setWrapperEl have the
correct nullable type and TypeScript no longer complains.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7bc3dd1b-f133-42cf-a4f8-449bf22035e9

📥 Commits

Reviewing files that changed from the base of the PR and between a2c9ca2 and 263ebe1.

📒 Files selected for processing (2)
  • src/Preview/index.tsx
  • tests/preview.test.tsx


// =========================== Focus ============================
useLockFocus(open && portalRender, () => wrapperRef.current);
useEffect(() => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

 改成 useLayouEffect,这样就不会强依赖于 useLockFocus 的先后顺序了。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

const imgRef = useRef<HTMLImageElement>();
const wrapperRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement>(null);
const [wrapperEl, setWrapperEl] = useState<HTMLDivElement>(null);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

这里有点奇怪,ref 是正解。setState 是因为 useLockFocus 调用的时候拿不到 dom 吗?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown

@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.

♻️ Duplicate comments (1)
src/Preview/index.tsx (1)

394-400: ⚠️ Potential issue | 🟠 Major

焦点陷阱根因仍可能未完全修复(首次目标为 null 的场景)

Line 400 仍依赖 wrapperRef.current,当前实现没有在 wrapper 挂载时触发一次“节点就绪后的重计算”。若首次激活时 CSSMotion 仍返回 nulluseLockFocus 可能继续错过锁定时机。建议把 wrapper 改为 callback ref + state,并把该 state 作为锁焦目标。

可选修复示例
- const wrapperRef = useRef<HTMLDivElement>(null);
+ const [wrapperNode, setWrapperNode] = useState<HTMLDivElement | null>(null);

- useLockFocus(open && portalRender, () => wrapperRef.current);
+ useLockFocus(open && portalRender, () => wrapperNode);

...
- <div
-   ref={wrapperRef}
+ <div
+   ref={setWrapperNode}
    className={clsx(prefixCls, rootClassName, classNames.root, motionClassName, {
#!/bin/bash
# 只读核验:确认当前实现是否仍是 useRef + useLockFocus(wrapperRef.current)
rg -n -C2 'const wrapperRef = useRef|useLockFocus\(|ref=\{wrapperRef\}|setWrapperNode' src/Preview/index.tsx
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Preview/index.tsx` around lines 394 - 400, The focus-trap can miss the
mount when wrapperRef.current is null — replace the ref-based wrapperRef with a
callback ref + state (e.g., setWrapperNode / wrapperNode) and pass that state
value into useLockFocus instead of wrapperRef.current so the hook re-runs when
the node becomes available; update the element ref passed to the wrapper's JSX
(currently using ref={wrapperRef}) to the callback ref, and keep
triggerRef/useLayoutEffect logic as-is so first-active-element handling
(triggerRef) still works when CSSMotion initially returns null.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/Preview/index.tsx`:
- Around line 394-400: The focus-trap can miss the mount when wrapperRef.current
is null — replace the ref-based wrapperRef with a callback ref + state (e.g.,
setWrapperNode / wrapperNode) and pass that state value into useLockFocus
instead of wrapperRef.current so the hook re-runs when the node becomes
available; update the element ref passed to the wrapper's JSX (currently using
ref={wrapperRef}) to the callback ref, and keep triggerRef/useLayoutEffect logic
as-is so first-active-element handling (triggerRef) still works when CSSMotion
initially returns null.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cbc4eced-27c0-4ff8-8994-d3abf76f589c

📥 Commits

Reviewing files that changed from the base of the PR and between 263ebe1 and ad46478.

📒 Files selected for processing (2)
  • package.json
  • src/Preview/index.tsx
✅ Files skipped from review due to trivial changes (1)
  • package.json

@zombieJ zombieJ merged commit 3540762 into react-component:master Apr 8, 2026
7 of 8 checks passed
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.

3 participants