-
Notifications
You must be signed in to change notification settings - Fork 193
feat: Support useLockFocus #712
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
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,9 @@ | ||
| --- | ||
| title: Focus Utils | ||
| --- | ||
|
|
||
| # Focus Utils Demo | ||
|
|
||
| Demonstrates the usage of focus-related utility functions. | ||
|
|
||
| <code src="../examples/focus.tsx"></code> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import React, { useRef } from 'react'; | ||
| import { useLockFocus } from '../../src/Dom/focus'; | ||
| import './focus.css'; | ||
|
|
||
| export default function FocusDemo() { | ||
| const containerRef = useRef<HTMLDivElement>(null); | ||
| const [locking, setLocking] = React.useState(true); | ||
|
|
||
| useLockFocus(locking, () => containerRef.current); | ||
|
|
||
| return ( | ||
| <div style={{ padding: 32 }} className="focus-demo"> | ||
| <h2>Focus Utils Demo</h2> | ||
|
|
||
| {/* External buttons */} | ||
| <button onClick={() => setLocking(!locking)}> | ||
| Lock ({String(locking)}) | ||
| </button> | ||
|
|
||
| {/* Middle container - Tab key cycling is limited within this area */} | ||
| <div | ||
| ref={containerRef} | ||
| tabIndex={0} | ||
| style={{ | ||
| border: '2px solid green', | ||
| padding: 24, | ||
| margin: 16, | ||
| borderRadius: 8, | ||
| backgroundColor: '#f0f8ff', | ||
| }} | ||
| > | ||
| <button>Container Button 1</button> | ||
| <button>Container Button 2</button> | ||
| <button>Container Button 3</button> | ||
| </div> | ||
|
|
||
| {/* External buttons */} | ||
| <button>External Button 2</button> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,3 +1,4 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useEffect } from 'react'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import isVisible from './isVisible'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| type DisabledElement = | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -56,48 +57,6 @@ export function getFocusNodeList(node: HTMLElement, includePositive = false) { | |||||||||||||||||||||||||||||||||||||||||||||||||||
| return res; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| let lastFocusElement = null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** @deprecated Do not use since this may failed when used in async */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function saveLastFocusNode() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| lastFocusElement = document.activeElement; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** @deprecated Do not use since this may failed when used in async */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function clearLastFocusNode() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| lastFocusElement = null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** @deprecated Do not use since this may failed when used in async */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function backLastFocusNode() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (lastFocusElement) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 元素可能已经被移动了 | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| lastFocusElement.focus(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /* eslint-disable no-empty */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (e) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // empty | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| /* eslint-enable no-empty */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function limitTabRange(node: HTMLElement, e: KeyboardEvent) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (e.keyCode === 9) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const tabNodeList = getFocusNodeList(node); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const lastTabNode = tabNodeList[e.shiftKey ? 0 : tabNodeList.length - 1]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const leavingTab = | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| lastTabNode === document.activeElement || node === document.activeElement; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (leavingTab) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const target = tabNodeList[e.shiftKey ? tabNodeList.length - 1 : 0]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| target.focus(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| e.preventDefault(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| export interface InputFocusOptions extends FocusOptions { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| cursor?: 'start' | 'end' | 'all'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -137,3 +96,98 @@ export function triggerFocus( | |||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ====================================================== | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // == Lock Focus == | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ====================================================== | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| let lastFocusElement: HTMLElement | null = null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| let focusElements: HTMLElement[] = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+103
to
+104
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. 全局状态设计仍然存在多锁冲突问题。 之前的审查已经指出, 如果需要支持多个独立的焦点锁同时存在(例如嵌套的模态框),当前的全局状态管理会导致不正确和不可预测的行为。建议为每个 基于之前的审查意见。 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| function getLastElement() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return focusElements[focusElements.length - 1]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| function hasFocus(element: HTMLElement) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { activeElement } = document; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return element === activeElement || element.contains(activeElement); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| function syncFocus() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const lastElement = getLastElement(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { activeElement } = document; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (lastElement && !hasFocus(lastElement)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const focusableList = getFocusNodeList(lastElement); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| const matchElement = focusableList.includes(lastFocusElement as HTMLElement) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? lastFocusElement | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| : focusableList[0]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| matchElement?.focus(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| lastFocusElement = activeElement as HTMLElement; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| function onWindowKeyDown(e: KeyboardEvent) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (e.key === 'Tab') { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { activeElement } = document; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const lastElement = getLastElement(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const focusableList = getFocusNodeList(lastElement); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const last = focusableList[focusableList.length - 1]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (e.shiftKey && activeElement === focusableList[0]) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Tab backward on first focusable element | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| lastFocusElement = last; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else if (!e.shiftKey && activeElement === last) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Tab forward on last focusable element | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| lastFocusElement = focusableList[0]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Lock focus in the element. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * It will force back to the first focusable element when focus leaves the element. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function lockFocus(element: HTMLElement): VoidFunction { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (element) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Refresh focus elements | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| focusElements = focusElements.filter(ele => ele !== element); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| focusElements.push(element); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Just add event since it will de-duplicate | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| window.addEventListener('focusin', syncFocus); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| window.addEventListener('keydown', onWindowKeyDown, true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| syncFocus(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Always return unregister function | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| lastFocusElement = null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| focusElements = focusElements.filter(ele => ele !== element); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (focusElements.length === 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| window.removeEventListener('focusin', syncFocus); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| window.removeEventListener('keydown', onWindowKeyDown, true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+167
to
+172
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. 清理函数过早重置 lastFocusElement 导致焦点追踪失效。 在第 167 行,清理函数无条件地将 当多个元素依次被锁定和解锁时,如果某个中间元素解锁, 应用以下差异修复: // Always return unregister function
return () => {
- lastFocusElement = null;
focusElements = focusElements.filter(ele => ele !== element);
if (focusElements.length === 0) {
+ lastFocusElement = null;
window.removeEventListener('focusin', syncFocus);
window.removeEventListener('keydown', onWindowKeyDown, true);
}
};📝 Committable suggestion
Suggested change
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Lock focus within an element. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * When locked, focus will be restricted to focusable elements within the specified element. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * If multiple elements are locked, only the last locked element will be effective. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function useLockFocus( | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| lock: boolean, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| getElement: () => HTMLElement | null, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
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 if (lock) {
const element = getElement();
if (element) {
return lockFocus(element);
}
} |
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (lock) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const element = getElement(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (element) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return lockFocus(element); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [lock]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
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.
The
lastFocusElementandfocusElementsvariables are global mutable states. The current implementation ofgetLastElement()always returns the most recently added element tofocusElements, andsyncFocusandonWindowKeyDownexclusively operate on this single "last" element. This design implies that only one focus lock can be truly active at any given time, with newer locks overriding older ones. If the intention is to support multiple independent focus locks simultaneously (e.g., for nested modals or multiple active components), this global state management will lead to incorrect and unpredictable behavior. Consider encapsulating the state for eachlockFocusinstance or redesigning the global state to properly manage multiple active locks, perhaps by iterating throughfocusElementsor using a more robust stack/queue approach that respects the order and context of active locks.