-
-
Notifications
You must be signed in to change notification settings - Fork 242
feat: manage focus for accessible click/context popups #613
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,91 @@ | ||||||||||||||||||||||||||||||||
| import type * as React from 'react'; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const TABBABLE_SELECTOR = | ||||||||||||||||||||||||||||||||
| 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'; | ||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The const TABBABLE_SELECTOR =
'a[href], button, input, select, textarea, summary, [contenteditable], audio[controls], video[controls], [tabindex]:not([tabindex="-1"])'; |
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| function isTabbable(el: HTMLElement, win: Window): boolean { | ||||||||||||||||||||||||||||||||
| if (el.closest('[aria-hidden="true"]')) { | ||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| if ('disabled' in el && (el as HTMLButtonElement).disabled) { | ||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Checking for the
Suggested change
|
||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| if (el instanceof HTMLInputElement && el.type === 'hidden') { | ||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# 只读核验:定位跨 realm 敏感写法
rg -n "instanceof HTMLInputElement|document\.activeElement" src/focusUtils.ts src/Popup/index.tsxRepository: react-component/trigger Length of output: 326 🏁 Script executed: cat -n src/focusUtils.ts | head -80Repository: react-component/trigger Length of output: 2681 🏁 Script executed: cat -n src/Popup/index.tsx | sed -n '250,270p'Repository: react-component/trigger Length of output: 762 🏁 Script executed: # Check if there are any tests related to focusUtils
fd -e test -e spec | xargs grep -l "focusUtils\|isTabbable\|handlePopupTabTrap" 2>/dev/null | head -5Repository: react-component/trigger Length of output: 49 🏁 Script executed: # Check for any tests or documentation about iframe/cross-realm support
fd -type f \( -name "*.test.*" -o -name "*.spec.*" -o -name "README*" \) | xargs grep -l "iframe\|cross.realm\|cross.document" 2>/dev/nullRepository: react-component/trigger Length of output: 239 🏁 Script executed: # Check git history or issues directory for cross-realm discussions
find . -name "*.md" -o -name "*.txt" | xargs grep -i "iframe\|cross.realm\|cross.document\|cross.window" 2>/dev/null | head -20Repository: react-component/trigger Length of output: 49 🏁 Script executed: # Check if there are existing focus-related tests
fd -type f \( -name "*.test.*" -o -name "*.spec.*" \) | xargs grep -l "focus\|tabbable\|focusUtils" 2>/dev/nullRepository: react-component/trigger Length of output: 239 🏁 Script executed: # Find test files with correct syntax
fd "\.test\.|\.spec\." | head -20Repository: react-component/trigger Length of output: 465 🏁 Script executed: # Search for focus-related tests
rg -l "focus|tabbable|focusUtils" --type ts --type tsx --type js --type jsx 2>/dev/null | grep -i "test\|spec"Repository: react-component/trigger Length of output: 49 🏁 Script executed: # Check if container.contains works cross-document
rg -n "contains" src/focusUtils.tsRepository: react-component/trigger Length of output: 117 🏁 Script executed: # Look for any cross-document or shadow DOM concerns in the codebase
rg -i "ownerDocument|defaultView|cross.document|shadow" src/ | head -20Repository: react-component/trigger Length of output: 1286 🏁 Script executed: cat tests/focus.test.tsxRepository: react-component/trigger Length of output: 5444 🏁 Script executed: # Check what win parameter is used for in isTabbable
rg -A 5 "function isTabbable" src/focusUtils.tsRepository: react-component/trigger Length of output: 275 跨 realm(iframe)下的 instanceof 判定与 activeElement 读取需修正。 Line 13 使用全局 建议修正: 修复方案 function isTabbable(el: HTMLElement, win: Window): boolean {
- if (el instanceof HTMLInputElement && el.type === 'hidden') {
+ if (el instanceof win.HTMLInputElement && el.type === 'hidden') {
return false;
}
export function handlePopupTabTrap(
e: React.KeyboardEvent,
container: HTMLElement,
): void {
- const active = document.activeElement as HTMLElement | null;
+ const active = container.ownerDocument.activeElement as HTMLElement | null;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| const ti = el.getAttribute('tabindex'); | ||||||||||||||||||||||||||||||||
| if (ti !== null && Number(ti) < 0) { | ||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| const style = win.getComputedStyle(el); | ||||||||||||||||||||||||||||||||
| if (style.display === 'none' || style.visibility === 'hidden') { | ||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Checking
Suggested change
|
||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /** Visible, tabbable descendants inside `container` (in DOM order). */ | ||||||||||||||||||||||||||||||||
| export function getTabbableElements(container: HTMLElement): HTMLElement[] { | ||||||||||||||||||||||||||||||||
| const doc = container.ownerDocument; | ||||||||||||||||||||||||||||||||
| const win = doc.defaultView!; | ||||||||||||||||||||||||||||||||
| const nodeList = container.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR); | ||||||||||||||||||||||||||||||||
| const list: HTMLElement[] = []; | ||||||||||||||||||||||||||||||||
| for (let i = 0; i < nodeList.length; i += 1) { | ||||||||||||||||||||||||||||||||
| const el = nodeList[i]; | ||||||||||||||||||||||||||||||||
| if (isTabbable(el, win)) { | ||||||||||||||||||||||||||||||||
| list.push(el); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| return list; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| export function focusPopupRootOrFirst( | ||||||||||||||||||||||||||||||||
| container: HTMLElement, | ||||||||||||||||||||||||||||||||
| ): HTMLElement | null { | ||||||||||||||||||||||||||||||||
| const tabbables = getTabbableElements(container); | ||||||||||||||||||||||||||||||||
| if (tabbables.length) { | ||||||||||||||||||||||||||||||||
| tabbables[0].focus(); | ||||||||||||||||||||||||||||||||
| return tabbables[0]; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| if (!container.hasAttribute('tabindex')) { | ||||||||||||||||||||||||||||||||
| container.setAttribute('tabindex', '-1'); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| container.focus(); | ||||||||||||||||||||||||||||||||
| return container; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| export function handlePopupTabTrap( | ||||||||||||||||||||||||||||||||
| e: React.KeyboardEvent, | ||||||||||||||||||||||||||||||||
| container: HTMLElement, | ||||||||||||||||||||||||||||||||
| ): void { | ||||||||||||||||||||||||||||||||
| if (e.key !== 'Tab' || e.defaultPrevented) { | ||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const list = getTabbableElements(container); | ||||||||||||||||||||||||||||||||
| const active = document.activeElement as HTMLElement | null; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| if (!active || !container.contains(active)) { | ||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| if (list.length === 0) { | ||||||||||||||||||||||||||||||||
| if (active === container) { | ||||||||||||||||||||||||||||||||
| e.preventDefault(); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const first = list[0]; | ||||||||||||||||||||||||||||||||
| const last = list[list.length - 1]; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| if (!e.shiftKey) { | ||||||||||||||||||||||||||||||||
| if (active === last || active === container) { | ||||||||||||||||||||||||||||||||
| e.preventDefault(); | ||||||||||||||||||||||||||||||||
| first.focus(); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } else if (active === first || active === container) { | ||||||||||||||||||||||||||||||||
| e.preventDefault(); | ||||||||||||||||||||||||||||||||
| last.focus(); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: react-component/trigger
Length of output: 241
🏁 Script executed:
Repository: react-component/trigger
Length of output: 506
🏁 Script executed:
Repository: react-component/trigger
Length of output: 407
🏁 Script executed:
Repository: react-component/trigger
Length of output: 305
🏁 Script executed:
Repository: react-component/trigger
Length of output: 94
🏁 Script executed:
Repository: react-component/trigger
Length of output: 94
🏁 Script executed:
Repository: react-component/trigger
Length of output: 834
🏁 Script executed:
Repository: react-component/trigger
Length of output: 927
🏁 Script executed:
Repository: react-component/trigger
Length of output: 101
🏁 Script executed:
Repository: react-component/trigger
Length of output: 49
🏁 Script executed:
Repository: react-component/trigger
Length of output: 49
🏁 Script executed:
Repository: react-component/trigger
Length of output: 94
🏁 Script executed:
Repository: react-component/trigger
Length of output: 857
🏁 Script executed:
Repository: react-component/trigger
Length of output: 235
🏁 Script executed:
Repository: react-component/trigger
Length of output: 94
🏁 Script executed:
Repository: react-component/trigger
Length of output: 385
🏁 Script executed:
Repository: react-component/trigger
Length of output: 498
🏁 Script executed:
Repository: react-component/trigger
Length of output: 582
跨 document 场景下回焦判定失效
Line 258 使用全局
document.activeElement,在 iframe、Shadow DOM 等跨 document 容器中,该元素属于主文档而非 popup 所在文档,导致root.contains(active)判定失败,回焦功能无法生效。建议改为
root.ownerDocument.activeElement,同一问题也存在于src/focusUtils.ts:66的handlePopupTabTrap函数中。🔧 修复建议
src/Popup/index.tsx
src/focusUtils.ts
🤖 Prompt for AI Agents