Skip to content

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

Open
aojunhao123 wants to merge 1 commit intoreact-component:masterfrom
aojunhao123:fix/preview-focus-lock
Open

fix: lock focus inside preview when opened via keyboard#505
aojunhao123 wants to merge 1 commit 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 触发

🤓 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

…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

概览

Preview 组件的焦点管理逻辑进行了重构。用状态管理的 wrapperEltriggerRef 替换了原有的 wrapperRef。当预览打开时捕获当前活跃元素,关闭时将焦点恢复至触发元素。

变更

文件/内聚体 摘要
焦点管理重构
src/Preview/index.tsx
wrapperRef 替换为状态管理的 wrapperEl 和新的 triggerRef。修改焦点捕获时机:预览打开时保存当前焦点元素;预览隐藏时恢复焦点并清空 triggerRef。焦点锁定 hook 执行条件从 open 更新为 open && !!wrapperEl
焦点锁定测试
tests/preview.test.tsx
新增测试用例,模拟 getBoundingClientRect 以稳定焦点行为测试。验证键盘打开预览、焦点捕获、焦点困禁、以及关闭后焦点恢复的完整流程。

序列图

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

    User->>TriggerEl: 按 Enter 键打开预览
    TriggerEl->>Preview: 触发打开事件 (open=true)
    Preview->>Preview: 捕获 document.activeElement 到 triggerRef
    Preview->>FocusLock: 执行 (open && !!wrapperEl)
    FocusLock->>WrapperEl: 在 wrapper 内锁定焦点
    
    User->>Preview: 尝试聚焦 trigger 元素
    FocusLock->>FocusLock: 焦点困禁,保持在 wrapper 内
    
    User->>Preview: 按 Escape 关闭预览
    Preview->>Preview: onVisibleChanged(nextVisible=false)
    Preview->>TriggerEl: 恢复焦点到 triggerRef
    Preview->>Preview: 清空 triggerRef
Loading

预计代码审查工作量

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

相关联的 PR

建议审查者

  • zombieJ
  • yoyo837

诗歌

🐰 焦点舞蹈轻盈跃,
捕捉光彩瞬间间,
锁住视线莫逃脱,
关闭归家一刹那,
预览灵巧显风采!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed 标题准确描述了PR的主要变化:修复通过键盘打开预览时的焦点锁定问题,这与原始代码摘要和PR目标相符。
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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.

Comment on lines +373 to +374
triggerRef.current?.focus?.();
triggerRef.current = null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

When restoring focus, it's safer to check if the element is still in the document. Additionally, using { preventScroll: true } prevents the page from jumping if the trigger element has moved out of the viewport while the preview was open.

      if (triggerRef.current && document.body.contains(triggerRef.current)) {
        triggerRef.current.focus({ preventScroll: true });
      }
      triggerRef.current = null;

Comment on lines +395 to +397
if (open) {
triggerRef.current = document.activeElement as HTMLElement;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

To avoid overwriting the trigger element with an element from inside the preview (which can happen if open is toggled quickly during the closing animation), we should only capture the active element if it's currently outside the preview wrapper.

Suggested change
if (open) {
triggerRef.current = document.activeElement as HTMLElement;
}
if (open && !wrapperEl?.contains(document.activeElement)) {
triggerRef.current = document.activeElement as HTMLElement;
}

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.

Check this one

@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 (263ebe1).

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #505   +/-   ##
=======================================
  Coverage   99.43%   99.44%           
=======================================
  Files          17       17           
  Lines         532      538    +6     
  Branches      161      161           
=======================================
+ 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

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